Skip to content

feat(canvas) - Image To 3D #9342

Open
kappacommit wants to merge 21 commits into
invoke-ai:mainfrom
kappacommit:feat/image-to-3d-triposplat
Open

feat(canvas) - Image To 3D #9342
kappacommit wants to merge 21 commits into
invoke-ai:mainfrom
kappacommit:feat/image-to-3d-triposplat

Conversation

@kappacommit

@kappacommit kappacommit commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Utilizes Triposplat to enable converting a 2D image to 3D. You can then move the 3D object around the canvas, resize it, adjust the viewing angle, and then recommit it as a new flat 2D raster layer. This enables the ability to make angle adjustments that historically have been difficult to do.

Triposplat is far from perfect. The general workflow that you should expect to follow in regards to this process is:

  • Convert your raster layer to 3D
  • Position it as desired on the canvas
  • Convert it back to a raster layer
  • Re-paint it with inpainting (using a controlnet if necessary) to 'clean up' and return detail to the render. You will lose detail during the 2D -> 3D conversion process

This PR vendors the triposplat library. It is two small .py files, MIT licensed so compatible with our repo to be vendored. The triposplat library has NO dependencies. Not even transformers or diffusers, making it a clean and simple addition

To render the 3D object on the canvas, 2 new dependencies are required:

Demos

16x speed - run on my RTX 4070 8gb Laptop GPU

Video.Project3.mp4
Video.Project2.mp4
Video.Project4.mp4
Video.Project.mp4

Related Issues / Discussions

N/A

QA Instructions

Triposplat's models are of varying quality. Sometimes they're nearly perfect, and sometimes they're extremely deformed. If you get a bad roll, simply discard and run the conversion again, each conversion is non-deterministic and will produce a new result (this is a limitation of the triposplat library)

  • On any raster layer, right-click -> Convert to 3D
  • Isolate Subject will convert the 'primary subject' of the scene to 3D and automatically remove the background. The other option will retain the background.
  • The model (~4gb) is downloaded from HF on first run, and cached, similar to SAM/SAM2
  • Rotate, adjust the image
  • Convert it back to 2D

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

Your Name and others added 18 commits February 6, 2026 19:58
…rotatable 3D splat

Adds a "Convert to 3D" canvas feature: rasterize a layer, generate a 3D Gaussian
splat with TripoSplat (MIT), inspect/adjust perspective in an in-canvas 3D viewport
(orbit camera, floor grid, axis gizmo), and commit the framed view back as a new
raster layer.

Backend:
- image_to_3d invocation wrapping vendored TripoSplat (snapshot_download + RawModel
  cache wrapper, mixed-dtype device moves); remove_background toggle.
- Asset3DField/Asset3DOutput primitives.
- asset_files disk service + /api/v1/assets/i/{name} route for serving .ply assets.

Frontend:
- SplatOverlay (three.js + @sparkjsdev/spark): orbit/grid/axes/ViewHelper gizmo,
  transparent WYSIWYG capture, commit to a raster layer at the source rect.
- "Convert to 3D" raster-layer submenu (isolate subject / keep background).
- runGraphAndReturnOutput for non-image node output; AbortSignal-based cancel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iposplat

Resolved conflicts in 9 files: kept triposplat additions (Asset3D* fields/outputs,
ImageTo3DInvocation, three/@sparkjsdev/spark/@types/three deps) while taking
upstream for shared code and version bumps. Regenerated schema.ts from the merged
backend and pnpm-lock.yaml from the merged package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… live canvas

Replace the modal 3D "studio" with a world-pinned transparent viewport so the
splat composites live over the actual canvas content (WYSIWYG placement):

- Footprint rect is now mutable session state: drag the frame edges to move,
  corner handles to resize (shift = keep aspect), orbit inside to angle the
  object. Stage pan/zoom stays live outside the frame for precise placement.
- SplatScene renders transparent always (studio bg/grid/axes removed); the
  ViewHelper gizmo shows only on hover. Drawing buffer folds the stage scale
  into the pixel ratio (quantized, capped 4096) so the view is crisp at any
  canvas zoom.
- Overlay pins to canvas world coords via $stageAttrs (same pattern as
  CanvasTextOverlay); mount moved inside CanvasManagerProviderGate.
- Non-blocking loading state (frame + toolbar spinner instead of a backdrop),
  Escape cancels, commit bakes at the user-positioned rect.
- rectTransforms: pure move/corner-resize helpers with vitest coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in Pillow 13)

Pillow infers L / RGB / RGBA from the uint8 array shape, so behavior is
identical; verified against Pillow 11.3 with warnings-as-errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With invalidationBehavior 'immediately', RTK Query drops a forced refetch when
the target query is already in flight. A session served entirely by the
invocation cache completes within the lifetime of the SessionQueueStatus
refetch triggered by its own enqueue/pending events, so the terminal event's
invalidation was dropped and the stale mid-run counts stuck — pulsing progress
bar and a phantom queued item until the next queue activity.

Capture any in-flight getQueueStatus fetch before invalidating; on terminal
statuses, invalidate once more after it settles so the final counts always
land. Applies to both the owner and sanitized (multiuser) event branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sposal, tests

- Register TripoSplatModel in calc_model_size_by_data's dispatch (class moved to
  backend/image_util/triposplat/triposplat_model.py so model_util can import it)
  — previously the ~4-5GB pipeline was cached as size 0, invisible to RAM/VRAM
  room-making, OOMing next to a resident diffusion model. + regression test.
- Add the new required asset_files arg at the four InvocationServices test
  construction sites that were missed (their modules errored at fixture setup).
- Splat overlay session integrity: per-session token gates every completion
  write (no cross-session clobber), starting a new conversion aborts the prior
  one, the abort slot only clears if still owned, and completion preserves the
  user's current rect instead of the start-time rect.
- Dispose the SparkRenderer in SplatScene.dispose() — it owns sort/LOD web
  workers and GPU-side state that leaked on every overlay session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fixed seed made "Convert to 3D" on an unchanged layer hit the invocation
cache and return the identical splat — there was no way to try again from the
canvas. Full determinism isn't on offer anyway (the vendored decode stage uses
the unseeded global RNG), so trade the fixed seed for a fresh roll per click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…100%

three's ViewHelper.handleClick mixes visual coordinates (getBoundingClientRect,
event.clientX/Y) with layout sizes (offsetWidth, fixed 128px dim). The splat
viewport is CSS-scaled by the canvas stage transform, so those spaces disagree
whenever stage zoom != 1 and the gizmo's click hotspot drifts off the drawn
gizmo — axis clicks raycast into empty space and are silently ignored.

Invert ViewHelper's mixed-space formula (pure helper + tests) and feed
handleClick remapped coordinates so its NDC comes out correct at any zoom.
Identity at scale 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hints

OrbitControls keeps the horizon level and clamps at the poles, so camera orbit
alone cannot reach poses like a diagonal lean — and commit bakes what the
camera sees, so unreachable angles were unbakeable. Alt+drag now arcball-
rotates the splat root about its center on the camera's screen axes (successive
two-axis increments compose to any orientation, including roll), claimed in the
container's capture phase so OrbitControls never sees the gesture.

Toolbar hint now lists the full control set. Also clear the gizmo-click
anchor on pointerup so a blocked pointerdown can't pair with a stale one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… wrapping

Alt+drag collided with the color-picker hotkey and holding a modifier while
rotating was awkward — replace it with an Orbit view / Rotate object segmented
toggle in the overlay toolbar. In object mode a plain left-drag rotates the
splat (wheel zoom and right-drag pan still reach OrbitControls; gizmo-corner
clicks pass through so view snapping keeps working). Mode resets per session
and survives scene remounts on asset swap.

Toolbar now wraps gracefully on narrow panels: hint text breaks only between
segments and the button pair drops to its own row instead of clipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hint

contextmenu fires on right-button release, so every right-drag camera pan
ended by popping the canvas context menu over the overlay. Swallow the event
inside the viewport (preventDefault for the browser menu, stopPropagation for
the app's canvas menu trigger) and list right-drag pan in the control hints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While orbiting/panning/zooming (or rotating the object), a small crosshair
fades in at the point the gesture rotates around — controls.target for camera
gestures, the object origin in rotate-object mode. Constant screen size
(scaled by camera distance), drawn on top of the splat (no depth test — the
pivot usually sits inside the body), faded via the render loop, and always
hidden from capture() so it never bakes into a layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iposplat

# Conflicts:
#	invokeai/frontend/web/src/services/api/schema.ts
…he object center

Panning moves controls.target, but object rotation always spun the splat about
its own origin — the panned pivot felt inert in rotate-object mode and the
pivot marker meant different things per mode. Apply the rotation about
controls.target (position swings via R·(pos − pivot) + pivot), so every
gesture — orbit, pan, zoom, rotate-object — shares one pivot, which is what
the crosshair marker now always shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "transformers>=5.1.0" override predates upstream's own transformers 5.5
migration (it forced uv past compel 2.1.1's <5.0 cap, long since obsolete —
upstream now ships compel>=2.4 with transformers>=5.5,<5.6). Worse, uv
overrides REPLACE all declared requirements for the package, so this silently
erased upstream's <5.6 pin and let resolution pick 5.6+, which breaks
diffusers single-file SD loads. Restore upstream's line; pyproject now matches
upstream exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added api python PRs that change python files invocations PRs that change invocations backend PRs that change backend files services PRs that change app services frontend-deps PRs that change frontend dependencies frontend PRs that change frontend files labels Jul 7, 2026
@github-actions github-actions Bot added the python-tests PRs that change python tests label Jul 7, 2026
Your Name and others added 2 commits July 7, 2026 02:42
…he hint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… generated schemas

- python-checks: exclude the two vendored TripoSplat files from ruff (same
  "External code" pattern as mediapipe_face/mlsd/etc.) and fix import sorting
  in dependencies.py.
- openapi-checks: the committed openapi.json was never regenerated with the
  triposplat nodes; regenerate it (in a fresh CI-equivalent 3.11 env — the dev
  venv's older pydantic drops plain-class docstrings from the schema) and run
  prettier over it like CI does.
- typegen-checks: regenerate schema.ts from that openapi.json — restores the
  CacheStats @description block CI expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added Root python-deps PRs that change python dependencies labels Jul 7, 2026
… merge

tests/app/routers/test_session_queue_workflow_call.py arrived in the latest
upstream merge, written against upstream's InvocationServices signature —
this branch adds a required asset_files parameter, so all 10 tests errored at
fixture setup on every CI platform. Same fix as the other four fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lstein

lstein commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Very cool!

@Pfannkuchensack

Copy link
Copy Markdown
Collaborator

Findings

1. Medium - Generation and commit failures are swallowed; the user sees no error

invokeai/frontend/web/src/features/controlLayers/hooks/useEntityConvertTo3D.ts:99-104 and invokeai/frontend/web/src/features/controlLayers/components/SplatOverlay/CanvasSplatOverlay.tsx:188-191.

  • In the convert hook, when runGraphAndReturnOutput rejects (OOM, failed HuggingFace snapshot_download, an unsupported image, or any backend invocation error), the only handling is log.error(...) followed by clearSplatOverlay(). The overlay simply vanishes. The user who clicked "Convert to 3D" gets no toast, no message, nothing distinguishable from a cancel.
  • In handleCommit, a capture/upload failure runs log.error(...) then setIsCommitting(false) - the "Commit to layer" button silently re-enables with no explanation.
  • invokeai/frontend/web/src/features/controlLayers/components/SplatOverlay/SplatViewer.tsx:33-35: a loadFromUrl failure (asset 404, corrupt .ply, three.js/Spark chunk load failure) is also only logged; the frame stays blank with the spinner already gone.

Evidence chain: the established canvas convention is to surface failures via toast({ status: 'error', ... }) - see invokeai/frontend/web/src/features/controlLayers/hooks/copyHooks.ts:45-47,86 (problemCopyingLayer, copyRegionError). This feature deviates: every failure path is log-only. Scenario: a user on a machine without enough VRAM triggers a convert, the node fails, the overlay disappears, and they reasonably conclude the button is broken.

To expose this issue, add a test that stubs runGraphAndReturnOutput to reject and asserts an error toast is dispatched (the toast dispatch is isolatable logic and testable under vitest without DOM rendering). Otherwise flag for manual verification that a failed conversion and a failed commit both raise a user-visible error.

2. Low/Medium - Untranslated user-visible strings (i18n violation)

The feature hardcodes English throughout; none of these keys exist in invokeai/frontend/web/public/locales/en.json (verified: zero matches), while the sibling menu items localize (invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsFilter.tsx uses t('controlLayers.filter.filter')).

  • invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsConvertTo3D.tsx:19,23,26 - "Convert to 3D", "Isolate subject", "Keep background".
  • invokeai/frontend/web/src/features/controlLayers/components/SplatOverlay/CanvasSplatOverlay.tsx:
    • CONTROL_HINTS array lines 42-57 ("Drag to orbit view", "Right-drag to pan (moves the rotation point)", "Scroll to zoom", "Edges to move", "Corners to resize", "Drag to rotate object").
    • line 349 "Generating 3D...", line 356 "Orbit view", line 359 "Rotate object", line 374 "Cancel", line 383 "Commit to layer".

These are badges, buttons, and helper text shown to the user, so they must resolve through en.json. Scenario: any non-English locale renders the entire 3D overlay and its menu in English.

To expose this issue, add a translation-key lint/test around these components' label sources if that logic can be isolated; otherwise note there is no approved DOM test framework and require manual verification that the overlay renders localized labels.

3. Low - image_to_3d declares WithBoard/WithMetadata but ignores both

invokeai/app/invocations/image_to_3d.py:33 inherits WithMetadata, WithBoard, but invoke() (lines 54-96) never reads self.board or self.metadata. The .ply is written straight to disk via context._services.asset_files.save(...) with no board association and no metadata persisted.

Evidence chain: the node is a registered invocation, so it appears in the workflow editor with board and metadata input sockets. A user wiring a Board into this node will reasonably expect the asset to land on that board; it silently will not. The mixins add contract surface the handler does not honor.

To expose this issue, add a test that constructs ImageTo3DInvocation with a board field set and asserts the resulting behavior is defined (either the field is honored or the mixins are removed so the socket is not advertised).

4. Low - No test coverage for the security-relevant path-traversal guard or the new route's auth posture

  • invokeai/app/services/asset_files/asset_files_disk.py:50-58 (_resolve_path) is the only defense against traversal on GET /api/v1/assets/i/{asset_name}. It is untested. The guard itself reads correct (rejects non-bare names and re-checks is_relative_to), but a regression here is a direct file-disclosure risk and nothing would catch it.
  • invokeai/app/api/routers/assets.py:20 is intentionally unauthenticated (relying on UUID unguessability, matching the image binary endpoint), but unlike images it has no test documenting that intent. Contrast tests/app/routers/test_multiuser_authorization.py:400 (test_get_image_full_is_unauthenticated). A future auth sweep could "fix" this route as an oversight, or a reviewer cannot tell the omission is deliberate.

To expose these gaps, add a test that _resolve_path raises for "../secret", "a/b", "", ".", and an absolute path, and add a multiuser test asserting GET /api/v1/assets/i/... is reachable without a token by design.

Open Questions

  • Assets have no DB record, no user_id, and no sharing flags - unlike images, which carry per-user ownership. In multiuser mode this means there is no ownership model at all for .ply assets. It is not a leak in practice because asset names are server-generated UUIDs returned only to the generating user's own graph run (not enumerable), giving parity with the image binary endpoint. Confirm this parity is the intended security posture rather than an oversight.
  • The bundled queue-status race fix in invokeai/frontend/web/src/services/events/setEventListeners.tsx:426-471,546-547 reads as correct (captures the in-flight getQueueStatus before invalidating, re-invalidates once after it settles, only on terminal statuses). It is unrelated to the 3D feature and ships with no regression test for the "phantom queue item" it targets - verify it was validated manually.

Residual Risk / Verification

  • No cleanup lifecycle exists for stored assets: AssetFilesServiceBase.delete is implemented but never called anywhere in the diff, so output/assets/*.ply accumulate indefinitely. This is a growth/hygiene risk, not a correctness defect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.x api backend PRs that change backend files frontend PRs that change frontend files frontend-deps PRs that change frontend dependencies invocations PRs that change invocations python PRs that change python files python-deps PRs that change python dependencies python-tests PRs that change python tests Root services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

4 participants